<!DOCTYPE html>
<html>
<head>
    <title>Scratch Link Test Client Fun Stuff Yay</title>
</head>
<body>
    <div><label for="log">Log</label></div>
    <textarea id="log" title="log" readonly style="width: 40rem; height: 10rem;"></textarea>
    <div>
        <input id="follow" type="checkbox" title="Follow" checked>
        <label for="follow">Follow</label>
    </div>
    <fieldset>
        <legend>BLE / micro:bit</legend>
        <div>
            <button id="initBLE">Connect to app</button>
            <button id="pingBLE">Request ping</button>
            <button id="discoverBLE">Find a micro:bit</button>
            <button id="connectBLE">Connect to discovered micro:bit</button>
            <button id="readBLE">Read and monitor data</button>
            <button id="writeBLE">Change the LEDs</button>
        </div>
    </fieldset>
    <fieldset>
        <legend>BT / EV3</legend>
        <div>
            <button id="initBT">Connect to app (BT)</button>
            <button id="discoverBT">Find BT devices</button>
            <button id="closeBT">Goodbye</button>
        </div>
        <div>
            <input id="peripheralId" value="0016533d0504" type="text" placeholder="Peripheral id">
            <button id="connectBT">Make friends</button>
        </div>
        <div>
            <input id="messageBody" style="width:20em;" value="DQAAAAAEAJkdAAAAAgFg" type="text" placeholder="data">
            <button id="send">Converse!</button>
            <button id="beep">Yell</button>
        </div>
    </fieldset>
    <script>
        class JSONRPC {
            constructor() {
                this._requestID = 0;
                this._openRequests = {};
            }

            /**
             * Make an RPC Request and retrieve the result.
             * @param {string} method - the remote method to call
             * @param {object} params - the parameters to pass to the remote method
             * @returns {Promise} - a promise for the result of the call
             */
            sendRemoteRequest(method, params) {
                const requestID = this._requestID++;

                const promise = new Promise((resolve, reject) => {
                    this._openRequests[requestID] = { resolve, reject };
                });

                this._sendRequest(method, params, requestID);

                return promise;
            }

            /**
             * Make an RPC Notification with no expectation of a result or callback.
             * @param {string} method - the remote method to call
             * @param {object} params - the parameters to pass to the remote method
             */
            sendRemoteNotification(method, params) {
                this._sendRequest(method, params);
            }

            /**
             * Handle an RPC request from remote.
             * @param {string} method - the method requested by the remote caller
             * @param {object} params - the parameters sent with the remote caller's request
             * @returns a result or Promise for result, if appropriate.
             */
            didReceiveCall(method, params) {
                throw new Error("Must override didReceiveCall");
            }

            /**
             * Send a JSON-style message object over the transport.
             * @param {object} jsonMessageObject - the message to send
             * @private
             */
            _sendMessage(jsonMessageObject) {
                throw new Error("Must override _sendMessage");
            }

            _sendRequest(method, params, id) {
                const request = {
                    jsonrpc: "2.0",
                    method,
                    params
                };

                if (id != null) {
                    request.id = id;
                }

                this._sendMessage(request);
            }

            _handleMessage(json) {
                if (json.jsonrpc !== '2.0') {
                    throw new Error(`Bad or missing JSON-RPC version in message: ${stringify(json)}`);
                }
                if (json.hasOwnProperty('method')) {
                    this._handleRequest(json);
                } else {
                    this._handleResponse(json);
                }
            }

            _sendResponse(id, result, error) {
                const response = {
                    jsonrpc: '2.0',
                    id
                };
                if (error != null) {
                    response.error = error;
                } else {
                    response.result = result || null;
                }
                this._sendMessage(response);
            }

            _handleResponse(json) {
                const { result, error, id } = json;
                const openRequest = this._openRequests[id];
                delete this._openRequests[id];
                if (error) {
                    openRequest.reject(error);
                } else {
                    openRequest.resolve(result);
                }
            }

            _handleRequest(json) {
                const { method, params, id } = json;
                const rawResult = this.didReceiveCall(method, params);
                if (id != null) {
                    Promise.resolve(rawResult).then(
                        result => {
                            this._sendResponse(id, result);
                        },
                        error => {
                            this._sendResponse(id, null, error);
                        }
                    );
                }
            }
        }

        class JSONRPCWebSocket extends JSONRPC {
            constructor(webSocket) {
                super();

                this._ws = webSocket;
                this._ws.onmessage = e => this._onSocketMessage(e);
                this._ws.onopen = e => this._onSocketOpen(e);
                this._ws.onclose = e => this._onSocketClose(e);
                this._ws.onerror = e => this._onSocketError(e);
            }

            dispose() {
                this._ws.close();
                this._ws = null;
            }

            _onSocketOpen(e) {
                addLine(`WS opened: ${stringify(e)}`);
            }

            _onSocketClose(e) {
                addLine(`WS closed: ${stringify(e)}`);
            }

            _onSocketError(e) {
                addLine(`WS error: ${stringify(e)}`);
            }

            _onSocketMessage(e) {
                addLine(`Received message: ${e.data}`);
                const json = JSON.parse(e.data);
                this._handleMessage(json);
            }

            _sendMessage(message) {
                const messageText = JSON.stringify(message);
                addLine(`Sending message: ${messageText}`);
                this._ws.send(messageText);
            }
        }

        class ScratchBLE extends JSONRPCWebSocket {
            constructor() {
                super(new WebSocket('wss://device-manager.scratch.mit.edu:20110/scratch/ble'));

                this.discoveredPeripheralId = null;
            }

            requestDevice(options) {
                return this.sendRemoteRequest('discover', options);
            }

            didReceiveCall(method, params) {
                switch (method) {
                case 'didDiscoverPeripheral':
                    addLine(`Peripheral discovered: ${stringify(params)}`);
                    this.discoveredPeripheralId = params['peripheralId'];
                    break;
                case 'ping':
                    return 42;
                }
            }

            read(serviceId, characteristicId, optStartNotifications = false) {
                const params = {
                    serviceId,
                    characteristicId
                };
                if (optStartNotifications) {
                    params.startNotifications = true;
                }
                return this.sendRemoteRequest('read', params);
            }

            write(serviceId, characteristicId, message, encoding = null) {
                const params = { serviceId, characteristicId, message };
                if (encoding) {
                    params.encoding = encoding;
                }
                return this.sendRemoteRequest('write', params);
            }
        }

        class ScratchBT extends JSONRPCWebSocket {
            constructor() {
                super(new WebSocket('wss://device-manager.scratch.mit.edu:20110/scratch/bt'));
            }

            requestDevice(options) {
                return this.sendRemoteRequest('discover', options);
            }

            connectDevice(options) {
                return this.sendRemoteRequest('connect', options);
            }

            sendMessage(options) {
                return this.sendRemoteRequest('send', options);
            }

            didReceiveCall(method, params) {
                switch (method) {
                    case 'didDiscoverPeripheral':
                        addLine(`Peripheral discovered: ${stringify(params)}`);
                        break;
                    case 'didReceiveMessage':
                        addLine(`Message received from peripheral: ${stringify(params)}`);
                        break;
                    default:
                        return 'nah';
                }
            }
        }

        self.Scratch = self.Scratch || {};

        function attachFunctionToButton(buttonId, func) {
            const button = document.getElementById(buttonId);
            button.onclick = () => {
                try {
                    func();
                } catch (e) {
                    addLine(`Button ${buttonId} caught exception: ${stringify(e)})`);
                }
            }
        }

        function initBLE() {
            addLine('Connecting...');
            self.Scratch.BLE = new ScratchBLE();
            addLine('Connected.');
        }

        function pingBLE() {
            Scratch.BLE.sendRemoteRequest('pingMe').then(
                x => {
                    addLine(`Ping request resolved with: ${stringify(x)}`);
                },
                e => {
                    addLine(`Ping request rejected with: ${stringify(e)}`);
                }
            );
        }

        function discoverBLE() {
            Scratch.BLE.requestDevice({
                filters: [
                    { services: [0xf005] } // micro:bit
                ]
            }).then(
                x => {
                    addLine(`requestDevice resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`requestDevice rejected with: ${stringify(e)}`);
                }
            );
        }

        function connectBLE() {
            // this should really be implicit in `requestDevice` but splitting it out helps with debugging
            Scratch.BLE.sendRemoteRequest(
                'connect',
                { peripheralId: Scratch.BLE.discoveredPeripheralId }
            ).then(
                x => {
                    addLine(`connect resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`connect rejected with: ${stringify(e)}`);
                }
            );
        }

        function readBLE() {
            Scratch.BLE.read(0xf005, '5261da01-fa7e-42ab-850b-7c80220097cc', true).then(
                x => {
                    addLine(`read resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`read rejected with: ${stringify(e)}`);
                }
            );
        }

        function writeBLE() {
            const message = _encodeMessage('LINK');
            Scratch.BLE.write(0xf005, '5261da02-fa7e-42ab-850b-7c80220097cc', message, 'base64').then(
                x => {
                    addLine(`write resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`write rejected with: ${stringify(e)}`);
                }
            );
        }

        // micro:bit base64 encoding
        // https://github.com/LLK/scratch-microbit-firmware/blob/master/protocol.md
        function _encodeMessage(message) {
            const output = new Uint8Array(message.length);
            for (let i = 0; i < message.length; i++) {
                output[i] = message.charCodeAt(i);
            }
            const output2 = new Uint8Array(output.length + 1);
            output2[0] = 0x81; // CMD_DISPLAY_TEXT
            for (let i = 0; i < output.length; i++) {
                output2[i + 1] = output[i];
            }
            return base64 = window.btoa(String.fromCharCode.apply(null, output2));
        }

        attachFunctionToButton('initBLE', initBLE);
        attachFunctionToButton('pingBLE', pingBLE);
        attachFunctionToButton('discoverBLE', discoverBLE);
        attachFunctionToButton('connectBLE', connectBLE);
        attachFunctionToButton('readBLE', readBLE);
        attachFunctionToButton('writeBLE', writeBLE);


        function initBT() {
            addLine('Connecting...');
            self.Scratch.BT = new ScratchBT();
            addLine('Connected.');
        }

        function discoverBT() {
            Scratch.BT.requestDevice({
                majorDeviceClass: 8,
                minorDeviceClass: 1
            }).then(
                x => {
                    addLine(`requestDevice resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`requestDevice rejected with: ${stringify(e)}`);
                }
            );
        }

        function connectBT() {
            Scratch.BT.connectDevice({
                peripheralId: document.getElementById('peripheralId').value,
                pin: "1234"
            }).then(
                x => {
                    addLine(`connectDevice resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`connectDevice rejected with: ${stringify(e)}`);
                }
            );
        }

        function sendMessage(message) {
            Scratch.BT.sendMessage({
                message: document.getElementById('messageBody').value,
                encoding: 'base64'
            }).then(
                x => {
                    addLine(`sendMessage resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`sendMessage rejected with: ${stringify(e)}`);
                }
            );
        }

        function beep() {
            Scratch.BT.sendMessage({
                message: 'DwAAAIAAAJQBgQKC6AOC6AM=',
                encoding: 'base64'
            }).then(
                x => {
                    addLine(`sendMessage resolved to: ${stringify(x)}`);
                },
                e => {
                    addLine(`sendMessage rejected with: ${stringify(e)}`);
                }
            );
        }

        function stringify(o) {
            return JSON.stringify(o, o && Object.getOwnPropertyNames(o));
        }

        const follow = document.getElementById('follow');
        const log = document.getElementById('log');

        const closeButton = document.getElementById('closeBT');
        closeButton.onclick = () => {
            self.Scratch.BT.dispose();
        };

        attachFunctionToButton('initBT', initBT);
        attachFunctionToButton('discoverBT', discoverBT);
        attachFunctionToButton('connectBT', connectBT);
        attachFunctionToButton('send', sendMessage);
        attachFunctionToButton('beep', beep);

        class LogDisplay {
            constructor (logElement, lineCount = 256) {
                this._logElement = logElement;
                this._lineCount = lineCount;
                this._lines = [];
                this._dirty = false;
                this._follow = true;
            }

            addLine (text) {
                this._lines.push(text);
                if (!this._dirty) {
                    this._dirty = true;
                    requestAnimationFrame(() => {
                        this._trim();
                        this._logElement.textContent = this._lines.join('\n');
                        if (this._follow) {
                            this._logElement.scrollTop = this._logElement.scrollHeight;
                        }
                        this._dirty = false;
                    });
                }
            }

            _trim () {
                this._lines = this._lines.splice(-this._lineCount);
            }
        }

        const logDisplay = new LogDisplay(log);
        function addLine(text) {
            logDisplay.addLine(text);
            logDisplay._follow = follow.checked;
        }
    </script>
</body>
</html>
